Skip to content

feat: zaino-store — block store with LMDB persistence, Lean formaliza…#1378

Open
hhanh00 wants to merge 1 commit into
zingolabs:devfrom
hhanh00:store
Open

feat: zaino-store — block store with LMDB persistence, Lean formaliza…#1378
hhanh00 wants to merge 1 commit into
zingolabs:devfrom
hhanh00:store

Conversation

@hhanh00

@hhanh00 hhanh00 commented Jul 7, 2026

Copy link
Copy Markdown

…tion, and sync improvements

Core changes:

  • Add zaino-store crate with LMDB-backed block storage (Chain, Freezer)
  • Bootstrap tool (zaino-admin) for offline import from Zebra DB
  • Lean formal model for freezer++chain invariants and sync algorithm
  • Adaptive fetch concurrency and per-phase timing in sync_step
  • Reorg handling: prev_hash validation, trim tool, snapshot tests
  • gRPC test tools (zaino-grpc-test, zaino-concurrent-test, zaino-check)
  • Remove Option<Arc> — LMDB is always required
  • Remove dead StateService backend and unused StoreError variants
  • Docker: build all workspace binaries, remove BuildKit-only flags
  • Config: wire start_height through, make -c/--config a global flag
  • Tracing: add journald support (Linux-gated), log reorgs as warnings
  • Docs: sync algorithm design doc, benchmarks, grant proposals

Fixes:

…tion, and sync improvements

Core changes:
- Add zaino-store crate with LMDB-backed block storage (Chain, Freezer)
- Bootstrap tool (zaino-admin) for offline import from Zebra DB
- Lean formal model for freezer++chain invariants and sync algorithm
- Adaptive fetch concurrency and per-phase timing in sync_step
- Reorg handling: prev_hash validation, trim tool, snapshot tests
- gRPC test tools (zaino-grpc-test, zaino-concurrent-test, zaino-check)
- Remove Option<Arc<LmdbStore>> — LMDB is always required
- Remove dead StateService backend and unused StoreError variants
- Docker: build all workspace binaries, remove BuildKit-only flags
- Config: wire start_height through, make -c/--config a global flag
- Tracing: add journald support (Linux-gated), log reorgs as warnings
- Docs: sync algorithm design doc, benchmarks, grant proposals
@zancas

zancas commented Jul 7, 2026

Copy link
Copy Markdown
Member

I'll start with the conflicts, I suppose.

@hhanh00

hhanh00 commented Jul 7, 2026 via email

Copy link
Copy Markdown
Author

Comment on lines +115 to +119
Every query takes a `&Snapshot` parameter. `snapshot_nonfinalized_state()` captures
an `Arc<NonfinalizedBlockCacheSnapshot>` and queries read from that frozen copy.
This prevents intra-request races at the cost of forcing every method to carry a
`&Snapshot` parameter — even methods that don't touch non-finalized state (like
`get_treestate`, which always passes through to the validator).

@nuttycom nuttycom Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If get_treestate always passes through to the validator and doesn't return tree states that corresponds to the snapshot, that's a bug in get_treestate because the treestate returned will be incompatible with the block that is represented as following it if the snapshot is of a branch that has been reorged relative to the validator's view.

Comment on lines +123 to +129
In the block store, blocks are inserted by hash. The height→hash mapping is
append-only on the best chain. A block's hash never changes. A height→hash
lookup returns the same answer forever or returns nothing. No snapshot needed.
The formal model's `height_consistent` invariant (Section 2 of
[`BlockStore.lean`](./BlockStore.lean)) ensures every non-genesis block's parent
exists at `height - 1`, and this invariant is maintained by the ingestion path,
not by freezing mutable state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also incorrect - a client needs a consistent view of the chain as of a particular state, the snapshot. When the client looks up the block hash for a given height as of the snapshot, this needs to be the block height relative to that snapshot's view of the chain tip; if the primary has reorged to a different chain, then getting the block hash for that other chain while using the snapshot will also produce incoherent/invalid history within the client.

@hhanh00

hhanh00 commented Jul 7, 2026 via email

Copy link
Copy Markdown
Author

Comment on lines +135 to +152
Of the 9 `ChainIndex` methods that take a `&Snapshot`, here's what actually
needs it:

| Method | Takes `&Snapshot` | Genuine race without it? |
|---|---|---|
| `get_block_hash(height)` | Yes | **No** — single lookup, stale the moment it returns |
| `get_indexed_block_by_height(height)` | Yes | **No** — same |
| `get_block_range(start, end)` | Yes | **No** — client can't observe inconsistency across stream items |
| `get_compact_block(height)` | Yes | **No** — single lookup |
| `get_compact_block_stream(start, end)` | Yes | **No** — same |
| `get_transaction_status(txid)` | Yes | **No** — hash-based main path; best-chain check is advisory |
| `find_fork_point(hash)` | Yes | **No** — hash-based walk; best-chain check is advisory |
| `get_raw_transaction(txid)` | Yes | **No** — only peeks at tip for mempool branch ID |
| `get_tx_out_set_info` | No (creates its own) | **Yes** — accumulator walk across all heights would corrupt mid-reorg |

1 out of 9. And `get_tx_out_set_info` is not a gRPC method — it's an internal
JSON-RPC handler. The snapshot adds a `&Snapshot` parameter to 8 methods that
don't need it, purely to satisfy a consistency requirement that doesn't exist.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is incoherent. The whole point of a snapshot is to present a coherent, stable view of the chain as of some particular chain tip. Reads through the snapshot should never return data for an alternate consensus chain, and it should be possible for a client holding a snapshot to make an arbitrary number of queries through the snapshot that all return results consistent to that snapshot until the in-memory handle to the snapshot is released.

@hhanh00

hhanh00 commented Jul 7, 2026 via email

Copy link
Copy Markdown
Author

@hhanh00

hhanh00 commented Jul 7, 2026 via email

Copy link
Copy Markdown
Author

@idky137

idky137 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Hey, sorry Ive been slow to take a proper look at this, Ironwood coincided with a holiday so haven't had much extra time.

This looks really interesting but I do have several architectural concerns with the design in its current form. I will not repeat the snapshot issues already raised, but they form part of the wider concerns below.

1. Non-finalised state

The proposed persistent-vector structure may be able to support snapshots, but the current API does not preserve a stable chain view across related requests. This needs to be resolved at the storage and API level, rather than treating snapshots as unnecessary.

The current non-finalised state also retains recent non-best-chain blocks. These are needed for chain-tip enumeration, transaction status on forks, fork-point discovery and deterministic reorg handling. The proposed design appears to retain only the current best-chain path, so it is unclear how these behaviours would be preserved.

2. Finalised state

The proposed database removes much of the integrity model built into the current FinalisedState. Zaino should not perform full consensus validation, but it should verify that the persistent data it owns is internally correct before serving it.

The current design includes lightweight checks for record integrity, chain continuity, transaction Merkle roots, transparent spend indexes and, when enabled, transparent address-history indexes. It is also structured to support stronger checks such as FlyClient-style verification. The replacement needs an equivalent corruption and tamper-detection model, potentially using authenticated hashes or another cryptographic verification scheme.

There is also no database versioning or migration strategy. Could this backend be introduced through the migration and primary/shadow routing system being completed in PR #1347? That system was designed to allow a new database to be built, validated and promoted while the old database continues serving requests.

Relatedly, the current router and capability model are designed to allow different consumers to use different database functionality. For example, Zallet does not need compact blocks. The database is separated into tables and capabilities so different versions or modes can load and serve only the required indexes.

This new store may therefore make sense as a replacement for the compact-block-serving portion of the database, while remaining inside a larger versioned database environment containing the additional tables and indexes.

3. API and index scope

The proposed backend currently looks primarily like a compact-block streamer, while Zaino is being developed as a broader indexing service.

The architecture needs to account for:

  • the snapshot-aware ChainIndex API;
  • the JSON-RPC services that Zaino currently serves or is intended to take over;
  • core indexes such as txid -> location and outpoint -> spending transaction;
  • transparent address transactions, balances, UTXOs and deltas;
  • experimental and planned wallet indexes;
  • transaction status, fork discovery, commitment-tree data and txout-set information.

Finally, this should not introduce a second incompatible finality and fork model. Current dev deliberately separates:

  • a persistent finalised best-chain database;
  • a non-finalised state containing recent blocks from all known chains;
  • the mempool.

That separation supports much more than snapshotting: it is also required for non-best-chain queries, reorg paths and consistent service behaviour. The new store should either fit beneath that model or provide an equivalent complete replacement for it.

Additional indexes that zaino currently serves (independant of zebra):

  • gettxoutsetinfo (JsonRPC)
  • getchaintips (JsonRPC, ChainIndex)
  • getspentinfo (JsonRPC)
  • OutpointSpenders (ChainIndex - Vec -> Vec<Option>)
  • ForkPoint (ChainIndex)
  • TransactionStatus(ChainIndex)

There are also larger plan to move indexing from zebra into zaino which should be taken into account, currently there is an alpha "transparent address indexing" feature that is not yet in production but should be considered.

@idky137

idky137 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Could you clarify the scope of the Lean proofs here? My understanding is that they verify properties of the in-memory chain/vector model, but may not cover the LMDB persistence layer, crash recovery, corruption, migrations, concurrent snapshots, or the wider non-finalised fork model. Are those behaviours included in the formal model, or are they expected to be validated separately?

@hhanh00

hhanh00 commented Jul 15, 2026 via email

Copy link
Copy Markdown
Author

@zancas
zancas requested a review from nachog00 July 17, 2026 15:28
@idky137

idky137 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Hey, I have been exploring whether the im::vector approach here could be adapted to retain the wider ChainIndex requirements, including competing forks and the current snapshot API.

The proposed shape is roughly:

pub struct ChainHead {
    current: ArcSwap<ChainHeadSnapshot>,
}

pub struct ChainHeadSnapshot {
    version: ChainHeadVersion,

    /// Active best chain, from the retained anchor to the tip.
    trunk: ChainPath,

    /// Competing chain suffixes only.
    ///
    /// With `max_chain_heads = 10`, this contains at most 9 branches.
    branches: BranchTable,

    /// Derived immutable lookup index.
    ///
    /// Each entry points to the same `Arc<IndexedBlock>` already held by
    /// the trunk or branch paths, so block payloads are not duplicated.
    directory: HashMap<BlockHash, BlockPresence>,
}

pub struct ChainPath {
    /// Height represented by `blocks[0]`.
    start_height: Height,

    /// Dense, height-ordered best-chain blocks.
    blocks: im::Vector<Arc<IndexedBlock>>,
}

pub struct BranchPath {
    /// Stable slot within this snapshot.
    id: BranchId,

    /// Block on the current trunk from which this branch diverges.
    fork: BlockIndex,

    /// Blocks from `fork.height + 1` through the branch tip.
    suffix: im::Vector<Arc<IndexedBlock>>,

    tip_chainwork: ChainWork,
}

pub struct BranchTable {
    slots: [Option<BranchPath>; 9],
}

pub struct BlockPresence {
    block: Arc<IndexedBlock>,

    /// Records whether the block is on the trunk and/or which branch
    /// suffixes contain it.
    paths: PathMask,
}

Branches store only their suffix after the common trunk, rather than duplicating a complete chain. Branches that share part of a suffix can hold clones of the same Arc values and may also share im::Vector structure.

When a competing branch becomes best, the implementation constructs a new trunk, converts the old best-chain suffix into a competing branch, renormalises the remaining branch paths against the new trunk, and publishes the complete result through the single ArcSwap.

The single publication root is important for snapshot consistency. A caller loads one immutable Arc and can chain several reads or drive a range stream against exactly that state. A reorg publishes a new snapshot without mutating the old one, so existing readers continue observing a coherent historical branch.

This should support the existing API efficiently:

height and range reads index or iterate the ordered trunk vector;
hash reads use the immutable directory;
getchaintips reads the trunk tip and occupied branch tips;
fork-point and non-best-chain queries use the branch metadata and path mask;
transaction and spend indexes can be added as derived immutable snapshot indexes where scanning blocks would be too expensive.

For high-concurrency workloads, readers take no shared lock. Short requests borrow one ArcSwap snapshot, while long-lived streams retain one owned snapshot. The directory does not duplicate block data: it stores another Arc to the same block allocation. The hot read path should borrow the block through the snapshot rather than cloning the Arc per request.

I would appreciate feedback on whether this preserves the performance and persistent-snapshot goals of this implementation while providing the fork-aware semantics required by ChainIndex.

@hhanh00

hhanh00 commented Jul 21, 2026

Copy link
Copy Markdown
Author

Could you describe the "wider ChainIndex requirements, including competing forks and the current snapshot API." ?

@idky137

idky137 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Ye sure, for chain branch tracking the main things we need are:

  • retaining recently observed competing forks within the configured reorg window
  • looking up retained blocks by either height or hash
  • reporting all retained chain tips and their fork points
  • distinguishing transactions or blocks on the active best chain from those on a competing branch
  • supporting reorg handling where a competing branch becomes best and the old best-chain suffix remains available as a non-best branch

And for snapshotting the requirement, callers must be able to obtain a fixed non-finalised-state snapshot and use it across several related operations. Those operations must all observe the same trunk, branches and tip, even if the live state advances or reorgs concurrently.

The intent of the proposed structure is to preserve the persistent, efficient best-chain representation from this PR while adding the bounded fork topology and coherent snapshot semantics.

@hhanh00

hhanh00 commented Jul 21, 2026 via email

Copy link
Copy Markdown
Author

@zcash-shielded-assets

Copy link
Copy Markdown

...we shift H2Hashes. We also delete these hashes from the PHM.

@idky137

idky137 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The previous version of this PR was actually closer to these requirements than this one. The idea was to keep a persistent hash map (PHM, hash -> block) in the recent chain state (C). Since a block cannot change hash, this is an append only hash map (good for perf). The best chain is a Vector. There was a height -> hashes: Vec<Vec> (no need for persistence here, this is only used in the ingestion thread, for GC). You could add a tips: Vector<(Hash, Hash, Height)> (it's cheap to walk backward and reverse since it's done occasionally and up to 100 blocks). - retaining recently observed competing forks within the configured reorg window: from tips, get hash, fork, height - looking up retained blocks by either height or hash: PHM + H2Hashes, - reporting all retained chain tips and their fork points: tips - distinguishing transactions or blocks on the active best chain from those on a competing branch: get the hash, compare with best chain hash - supporting reorg handling where a competing branch becomes best and the old best-chain suffix remains available as a non-best branch: during ingestion, we build a new PHM, H2Hashes, tips instance and ArcSwap it. During chain shift, we shift H2Hashes, and purge old chain tips. I moved to a simpler Vector because then GC old forks is automatic.

On Tue, Jul 21, 2026 at 6:22 PM idky137 @.> wrote: idky137 left a comment (zingolabs/zaino#1378) <#1378 (comment)> Ye sure, for chain branch tracking the main things we need are: - retaining recently observed competing forks within the configured reorg window - looking up retained blocks by either height or hash - reporting all retained chain tips and their fork points - distinguishing transactions or blocks on the active best chain from those on a competing branch - supporting reorg handling where a competing branch becomes best and the old best-chain suffix remains available as a non-best branch And for snapshotting the requirement, callers must be able to obtain a fixed non-finalised-state snapshot and use it across several related operations. Those operations must all observe the same trunk, branches and tip, even if the live state advances or reorgs concurrently. The intent of the proposed structure is to preserve the persistent, efficient best-chain representation from this PR while adding the bounded fork topology and coherent snapshot semantics. — Reply to this email directly, view it on GitHub <#1378?email_source=notifications&email_token=AABWIH5TKJNTZ2F2GI2IDZT5F6KFLA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMBTGY2DEOJQGA32M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#issuecomment-5036429007>, or unsubscribe https://github.com/notifications/unsubscribe-auth/AABWIH5XBSVEA4H7WDGFV6L5F6KFLAVCNFSNUABFKJSXA33TNF2G64TZHM3TINJWHAZDIMBVHNEXG43VMU5TIOBSGYYDSNJRGYZ2C5QC . Triage notifications, keep track of coding agent tasks and review pull requests on the go with GitHub Mobile for iOS https://github.com/notifications/mobile/ios/AABWIH56Z74SS67D3YW5FG35F6KFLA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMBTGY2DEOJQGA32M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KUZTPN52GK4S7NFXXG and Android https://github.com/notifications/mobile/android/AABWIH24QA6USLME5NWG7WD5F6KFLA5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTKMBTGY2DEOJQGA32M4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2K4ZTPN52GK4S7MFXGI4TPNFSA. Download it today! You are receiving this because you authored the thread.Message ID: @.>

Ahh ok, is this implementation available to view anywhere? I think I missed this as I have been basing review on the current PR.

This earlier design looks more like the right foundation for Zaino. However I wonder if it would make sense to try to utilise the benefits of the current im::vector approach for the best chain (since the majority of zaino's indexing will be done here)?

A possible shape could be:

pub struct ChainHead {
    current: ArcSwap<ChainHeadSnapshot>,
}

pub struct ChainHeadSnapshot {
    /// Active best chain, retained base through tip.
    chain: im::Vector<Arc<IndexedBlock>>,

    /// Every unique retained block across the active chain and forks.
    blocks: im::HashMap<BlockHash, Arc<IndexedBlock>>,

    /// Active and competing tips, including their fork points.
    tips: ArrayVec<ChainTip, 10>,
}

pub struct ChainTip {
    tip: BlockIndex,
    fork: BlockIndex,
    (chainwork: ChainWork,)?
}

/// Single-writer ingestion/GC state, not exposed to readers.
pub struct ChainHeadWriterState {
    hashes_by_height: VecDeque<Vec<BlockHash>>,
}

This keeps the main benefits of your earlier design: forks are represented implicitly through parent hashes, each unique block is stored once, reorgs mainly replace the canonical path and tip metadata, and expired blocks can be removed efficiently using hashes_by_height.

The main difference is storing Arc directly in the canonical vector rather than hashes. The vector and PHM reference the same block allocation, so payloads are not duplicated, but best-chain ranges can be served directly without a PHM lookup for every block. This seems worthwhile for Zaino because block ingestion and reorgs are relatively infrequent, while canonical block ranges may be served to thousands of concurrent clients.

Publishing the chain, block map and tips through one ArcSwap also preserves the current snapshot semantics: existing readers retain a coherent historical view while a complete new state is constructed and published after an extension, reorg or GC operation.


One other area I have been looking into is serving the more complex indexes (outpoint->spending_txid, transparent_address_history, etc ), this is because the max_non_finalised_depth changing from 100 block to 1000 block meas that full nfs searches - ie for transaction status we would currently have to iter through up to 10,000 blocks (max_depth=1000 * max_chain_forks=10), checking all txids in each block for matches - which may be too inefficient when serving thousands of concurrent clients.

An idea I have been looking at is separating out the nfs/ChainHead interface into several ports / interfaces, with the non "core" ports building extra indexes in the nfs/ChainHead snapshot to serve these indexes more efficiently.

A possible split would be:

ChainHeadBlockService - always enabled. Owns the core snapshot state (chain, blocks, tips) and serves block lookup/ranges, tip and fork queries, ingestion, reorgs and GC.

ChainHeadTransactionService - feature-gated. Maintains:

txid -> [TransactionLocation]
outpoint -> [SpenderLocation]

This serves raw transaction lookup, transaction status across best and competing chains, and outpoint spender queries without scanning up to ~10,000 retained blocks. Canonicality is determined against the best-chain vector in the same snapshot, so reorgs do not require rebuilding these indexes.

ChainHeadTransparentHistoryService - feature-gated. Maintains:

transparent_address -> [TransparentHistoryEntry]

Each entry records the block, transaction and output/spend event needed to serve address balances, txids, UTXOs and deltas without rescanning the full 1,000-block best chain for every request.

For queries below the ChainHead base, the transaction and transparent services would depend on separate driven ports implemented by the consumer, for example:

trait ChainHeadTransactionSource {
async fn transaction_locations_below(...);
async fn outpoint_spenders_below(...);
}

trait ChainHeadTransparentHistorySource {
async fn address_history_below(...);
}

zaino-state could initially implement these adapters using the current finalised state, then switch to the new finalised-state interfaces later.

All enabled indexes should be published in the same ArcSwap as the core block state, so every block, tip, transaction and transparent query uses one coherent chain generation. This follows the same ports/adapters and optional-capability structure as the new mempool ( #1416 ) while keeping the core ChainHead usable on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants